由 布多(budo) 发布于 2025-05-21,更新于 2025-05-22
字符编码的起源
在计算机的世界里,所有信息最终都被转换成二进制数据存储。每个二进制位(bit)只有 0 和 1 两种状态,8 个二进制位组成一个字节(byte),可以表示 256 种不同的状态(2^8 = 256)。如果用一个状态对应一个字符,那么一个字节就能表示 256 个不同的字符,范围从 0x00
到 0xFF
。
20 世纪 60 年代,美国制定了 ASCII 编码标准,将英文字符与二进制数据一一对应。ASCII 编码定义了 128 个字符,包括英文字母、数字、标点符号等。例如,字母 A 对应的二进制值是 0x41
。注意:ASCII 只使用了字节中的低 7 位,最高位固定为 0。
对于英语来说,ASCII 的 128 个字符已经足够使用。但其他西欧语言,如法语,需要更多的字符来表示重音符号等特殊字符。于是人们开始利用字节中闲置的最高位来扩展字符集,这样最多可以表示 256 个字符。但这种扩展方式带来了新的问题:不同语言使用相同的二进制值却表示不同的字符。例如,二进制值 0x82
在法语中表示 é
,在希伯来语中表示 ג
,在俄语中又表示另一个字符。这种编码冲突导致了跨语言文本传输时出现乱码。
对于亚洲国家中、日、韩语言来说,情况更加复杂。以中文为例,汉字数量接近 10 万个,一个字节的 256 个字符远远不够。因此,必须使用多个字节来表示一个字符。这就是为什么后来出现了 GB2312 这样的编码标准,它使用两个字节(16位)来表示一个汉字,理论上可以容纳 65536 个字符(2^16 = 65536)。
Unicode 的诞生与发展
随着计算机技术的全球化发展,不同国家和地区使用各自的字符编码标准带来了严重的兼容性问题。同一个二进制数据在不同编码标准下可能被解释为完全不同的字符,这导致跨语言文本传输时频繁出现乱码,严重阻碍了信息的传播和交流。
为了解决这个问题,Unicode 应运而生。Unicode 是一个统一的字符编码标准,它为世界上所有的字符分配一个唯一的数字编号(称为码点,Code Point)。这些码点范围从 0x0000
到 0x10FFFF
,截止到 2025 年 5 月 21 号,Unicode 已经收录了 154,998 个字符(数据来源:Unicode 16.0.0)。
Unicode 采用了分层设计来高效管理如此庞大的字符集。它将整个字符空间划分为 17 个平面(Plane),每个平面包含 65536 个码点(2^16),理论上最多可以容纳 1,114,112 个字符(17 × 65536)。不过在实际使用中,并非所有码点都被使用,很多位置都是预留的。
这 17 个平面的具体分工如下:
- 基本多文种平面(BMP,Plane 0):范围是 U+0000 ~ U+FFFF,包含了最常用的字符,如 ASCII 字符、拉丁字母、中日韩统一表意常见文字等。
- 第一辅助平面(SMP,Plane 1):范围是 U+10000 ~ U+1FFFF,包含历史文字、不常用的符号以及最重要的表情符号(Emoji)等。
- 第二辅助平面(SIP,Plane 2):范围是 U+20000 ~ U+2FFFF,主要用于罕见的中日韩统一表意文字。
- 第三至第十三辅助平面:范围是 U+30000 ~ U+DFFFF,预留给未来使用。
- 第十四辅助平面(SSP):范围是 U+E0000 ~ U+EFFFF,包含一些非图形字符和特殊用途字符。
- 私人使用区(PUA):范围是 U+F0000 ~ U+10FFFD,供私人协议或应用内部使用,Unicode 联盟承诺永远不会给它们分配字符。
Unicode 的内部结构与特性
Unicode 的内部结构非常精密复杂,它不仅仅是简单地为字符分配编码那么简单。作为一个全球性的字符编码标准,Unicode 为每个字符都定义了丰富的属性信息,这些信息被存储在 Unicode 字符数据库(Unicode Character Database, UCD)中,用于支持文本的正确处理和显示。
Unicode 采用了巧妙的字符系统设计,将字符分为基础字符和组合字符两大类。基础字符可以独立显示,如字母、数字、汉字等;而组合字符(也称为组合标记)则不能单独使用,它们用于修饰基础字符。例如,基础字符 e
(U+0065) 加上组合字符 ́
(U+0301) 可以组合成 é
。一个基础字符可以跟随多个组合字符,最终显示为一个完整的字符。这种设计不仅使 Unicode 能够灵活地表示各种带重音符号、变音符号的字符,还大大减少了需要编码的字符数量。
为了实现全面而精确的字符处理,Unicode 为每个字符定义了多种属性:
- 通用类别:将字符分类为字母、数字、标点、符号等。如
Lu
表示大写字母、Ll
表示小写字母、Nd
表示十进制数字、Mn
表示非间距组合字符、Zs
表示空格字符等。 - 脚本属性:标识字符所属的书写系统,如拉丁语、西里尔语、阿拉伯语、汉语等。
- 双向性:定义字符在双向文本(如阿拉伯语和希伯来语)中的方向性行为。
- 数值属性:为表示数字的字符定义其对应的数值。
- 大小写映射:规定字符在大写、小写、标题大小写之间的转换规则。
- 连字和连接:控制字符与相邻字符的连接形态(常见于阿拉伯语等)。
- 空白属性:标识是否为空白字符。
由于同一个字符可能有多种 Unicode 表示形式(如 é
可以是单个码点 U+00E9
或 e(U+0065)
加 ´(U+0301)
),Unicode 定义了四种规范化形式来确保字符串比较的准确性:
- NFC:最常用的规范化形式,它会将所有预组合字符(如
é
)
表示为单个码点(U+00E9),而不是组合字符序列(如e
U+0065 +´
U+0301)。 - NFD:将预组合字符分解为基本字符和组合字符序列。
- NFKC:在 NFC 基础上进行兼容性转换(处理那些在视觉上相似但在语义上可能不同的字符,比如
ff
(U+FB00) 和ff
(U+0066 U+0066))。 - NFKD:在 NFD 基础上进行兼容性转换。
规范化的主要目的有:
- 确保字符串比较的准确性,如果没有规范化,
é
(U+00E9) 和é
(U+0065 U+0301) 会被视为不同的字符串,尽管它们看起来一样。规范化后,它们会变成相同的字节序列; - 保证搜索和排序结果的一致性;
- 优化数据存储效率,减少数据冗余;
在处理文本时,需要注意用户感知的”字符”与 Unicode 码点并非总是一一对应的。Unicode 引入了字素簇的概念,表示用户认知中的单个”字符”。一个字素簇可能由一个或多个码点组成,例如 é = e + ́
,一个表情符号也可能是一个码点(如😀)或多个码点(如👩👩👧👦)。
PS: ObjC 和 Java 中的字符串长度返回的是码点数量而非字素簇数量,而 Swift 中的
String.count
返回的是字素簇数量。
为了支持复杂的文本处理,Unicode 还提供了一系列重要算法:
双向文本算法(BiDi):对于混合了从左到右 (LTR) 和从右到左 (RTL) 书写方向的文本(如阿拉伯语混合英语),Unicode 定义了复杂的双向算法用于确定字符的最终显示顺序。它不是简单地从左到右或从右到左渲染,而是根据字符的方向属性和周围文本的上下文动态调整。
排序算法:对字符串进行排序看似简单,但在多语言环境中却非常复杂。Unicode 通过通用语言环境数据仓库 (CLDR) 和 Unicode 排序算法 (UCA),允许根据不同的语言和文化习惯进行正确的字符串排序。例如,在德语中,ä 可能被视为 a 或 ae,在瑞典语中,å 可能排在 z 之后。
UTF 的诞生
需要注意的是,Unicode 只是一个字符集标准,它定义了字符和数字编码之间的映射关系,但并没有规定这些编码应该如何在计算机中存储。这就带来了一些实际问题,比如:
存储效率问题:Unicode 为了能表示所有字符,使用了较大的码点范围(从 0x0000 到 0x10FFFF)。如果每个字符都使用相同大小的存储空间(比如 4 个字节),对于 ASCII 范围内的字符来说会造成巨大的空间浪费。
解析识别问题:当使用可变长度存储时,计算机需要知道如何正确解析字节序列。比如遇到字节序列 0x4E25 时,是将其解释为一个 Unicode 字符(汉字”严”),还是两个独立的字符?
为了解决这些问题,诞生了多种 Unicode 编码方案(Unicode Transformation Format, UTF),其中最重要的是 UTF-8、UTF-16 和 UTF-32。这些编码方案定义了 Unicode 码点在计算机中的具体存储规则。
UTF-8
让我们先看看 UTF-8,它是目前最广泛使用的 Unicode 编码方案。UTF-8 最大的特点是采用变长编码:
- 对于 ASCII 字符(0x00-0x7F),只使用 1 个字节,并且与 ASCII 编码完全兼容;
- 对于其他 Unicode 字符,根据其码点值的大小,使用 2-4 个字节存储;
UTF-8 的编码规则如下:
- 单字节编码:首位为 0,后面 7 位用于存储字符编码;
- n字节编码:
- 第一个字节以 n 个 1 开头(n 为总字节数),再接一个 0;
- 后续字节都以 10 开头;
- 剩余的位用于存储字符的 Unicode 编码值;
举例说明:
- ASCII 字符 ‘A’(U+0041):单字节
0x41
→0100 0001
; - 汉字 “严”(U+4E25):三字节
0xE4B8A5
→1110 0100, 1011 1000, 1010 0101
; - emoji “🍑”(U+1F351):四字节
0xF09F8D91
→1111 0000, 1001 1111, 1000 1101, 1001 0001
;
UTF-16
UTF-16 是另一种常见的 Unicode 编码方案,它采用了一种巧妙的变长编码设计:对于基本多文种平面(BMP)的字符使用 2 字节存储,而对于辅助平面的字符则使用 4 字节存储。这种设计既保证了编码效率,又解决了字符表示的问题。让我们详细了解一下它的工作原理:
基本多文种平面(BMP)字符的编码规则:
对于 BMP 范围内的字符(码点范围:0x0000 到 0xFFFF),UTF-16 采用直接存储的方式,将 Unicode 码点转换为 2 字节的二进制数据。例如:
- ASCII 字符 ‘A’(U+0041):存储为
0x0041
- 汉字 “严”(U+4E25):存储为
0x4E25
辅助平面字符的编码规则:
对于辅助平面的字符(码点范围:0x10000 到 0x10FFFF),UTF-16 采用了一种称为代理对(Surrogate Pair)的编码机制。这种机制的设计非常巧妙:
首先,UTF-16 在 BMP 中预留了一个特殊的区域(U+D800 到 U+DFFF),这个区域被称为代理区,专门用于代理对编码。
代理对编码的具体步骤:
- 将码点值减去 0x10000,得到一个 20 位的值;
- 将这 20 位分成两部分:高 10 位和低 10 位;
- 高 10 位加上 0xD800 得到高代理;
- 低 10 位加上 0xDC00 得到低代理;
- 最终用这两个代理值组成一个 4 字节的编码;
以 🍑
(U+1F351) 为例:
- 0x1F351 - 0x10000 = 0xF351(0b1111 0011 0101 0001);
- 高 10 位:0x3C(0b00 0011 1100) + 0xD800 = 0xD83C;
- 低 10 位:0x351(0b0011 0101 0001) + 0xDC00 = 0xDF51;
- 最终编码:0xD83CDF51;
注:UTF-16 编码需要考虑字节序问题,这里使用 Big endian 表示。关于字节序的详细说明,请参考后文的 字节序 小节。
UTF-16 的设计有其独特的优势:
对于 BMP 字符(包括大多数常用字符),UTF-16 可以直接存储,无需编码转换,这使得它在处理这些字符时性能优异。
对于 CJK 文本(中文、日文、韩文),UTF-16 特别高效,因为:
- 大多数 CJK 字符都在 BMP 范围内
- 每个字符只需要 2 字节存储
- 无需编码转换,直接存储
然而,UTF-16 也存在一些局限性:
- 不兼容 ASCII 编码,处理纯 ASCII 文本时会占用双倍存储空间
- 需要处理字节序问题
这些特性使得 UTF-16 特别适合处理 CJK 文本,但在其它场景下可能不如 UTF-8 灵活。
UTF-32
UTF-32 是 Unicode 编码方案中最简单直接的一种,它采用固定 4 字节编码,每个码点都直接映射到对应的 32 位值。这种设计使得 UTF-32 具有以下特点:
- 实现简单:无需复杂的编码解码算法,码点值直接存储
- 性能优异:在字符索引、长度计算等操作上效率最高
- 空间效率低:每个字符都占用 4 字节,存储空间利用率最低
- 应用场景:主要用于需要频繁进行字符操作的特殊场景,如文本编辑器、排版系统等
虽然 UTF-32 在性能上具有优势,但由于其巨大的存储开销,在实际应用中并不常见。它更适合作为内部处理格式,而不是存储或传输格式。
UTF 各个编码方案的对比
从性能角度分析,UTF-32 无疑是最优选择。它采用固定 4 字节编码,无需任何编码转换,字符的码点值直接存储,这使得它在字符索引、长度计算等操作上效率最高。
从存储空间效率来看,情况则更为复杂:
- UTF-32 的存储效率最低,每个字符都固定占用 4 字节。
- UTF-8 和 UTF-16 的存储效率则取决于文本内容:
- 对于 ASCII 字符(如英文字母、数字、标点),UTF-8 仅需 1 字节,而 UTF-16 需要 2 字节,此时 UTF-8 更优;
- 对于西欧字符(如拉丁文、西里尔字母),两者都需要 2 字节,但 UTF-16 因无需编码转换而性能更佳;
- 对于 CJK 字符(如中文、日文、韩文),UTF-8 需要 3 字节,而 UTF-16 仅需 2 字节,此时 UTF-16 在存储效率和性能上都占优。
表面上看,UTF-16 似乎是最佳选择,但为什么 UTF-8 却成为了互联网的主流编码方案?这要归功于其精妙的设计:
- 完美兼容 ASCII:对 ASCII 字符的编码与 ASCII 标准完全一致,保证了向后兼容性;
- 无字节序困扰:UTF-8 的编码规则天然避免了字节序问题,简化了跨平台处理;
- 灵活的变长编码:能够根据字符类型自适应调整编码长度,在保证效率的同时兼顾了通用性;
- 优秀的空间效率:在大多数实际应用场景中,其存储效率都相当可观。
这些特性使得 UTF-8 成为兼顾性能、兼容性与实用性的优秀编码方案,因此成为互联网的主流选择。
字节序
在讨论 Unicode 和 UTF 编码时,我们还需要了解一个重要概念:字节序(Byte Order)。字节序是指在计算机中存储多字节数据时,字节的排列顺序。这个概念对于 UTF-16 和 UTF-32 这样的编码尤为重要,因为它们需要多个字节来表示一个字符。字节序主要有两种:
- Big endian(大端序):高位字节在前,低位字节在后,就像我们平常写数字一样,从左到右。
- Little endian(小端序):低位字节在前,高位字节在后,可以理解为”反着写”。
让我们以汉字 严
为例,它的 Unicode 码点是 0x4E25
。在 UTF-16 编码中:
- 使用 Big endian 存储时:
0x4E
在前,0x25
在后 - 使用 Little endian 存储时:
0x25
在前,0x4E
在后
为了让计算机能够正确识别文件采用的字节序,Unicode 标准引入了字节序标记(BOM,Byte Order Mark)机制:
- 如果文件以
FE FF
开头:表示文件采用 Big endian 方式编码; - 如果文件以
FF FE
开头:表示文件采用 Little endian 方式编码;
值得注意的是,UTF-8 由于其特殊的编码规则,实际上不需要 BOM 来标识字节序。但有些编辑器可能会在 UTF-8 编码的文件开头添加 BOM,这主要是为了向后兼容。
这两种字节序的存在是历史原因造成的。不同的计算机架构在发展过程中采用了不同的字节序设计,为了保持兼容性,Unicode 不得不同时支持这两种方案。这也提醒我们在处理文本数据时,需要正确处理字节序问题,避免因字节序不匹配导致的乱码。
注意事项
Unicode 给我们的生活和交流带来了许多便利,但我们还需要注意一些潜在的安全风险。虽然 Unicode 的设计初衷是为了统一全球字符编码,但其庞大的字符集也被一些不法分子利用来进行欺诈:
- 同形异义字攻击(Homoglyph Attacks):利用 Unicode 中视觉上相似但实际码点不同的字符来进行欺骗。最典型的例子是域名欺诈:
apple.com
和аpple.com
看起来完全一样,但第一个域名使用的是拉丁字母 a(U+0061),第二个则是西里尔字母 а(U+0430)。这种攻击方式可能诱导用户访问恶意网站。
这种情况的存在实际上反映了 Unicode 的一个重要设计哲学 —— “Characters, not glyphs”(字符而非字形)。这一原则强调了字符和字形的本质区别:
- 字符(Character)是一个抽象的、语义层面的概念,代表了书写语言中具有独特含义的最小单位。它与具体的显示形式无关,更像是一个”概念”。
- 字形(Glyph)则是字符的具体视觉表现。同一个字符可以有多种字形(比如同一个字母 a 在不同的字体、字重下会有不同的显示效果),即使是完全相同的字形,也可能代表不同的字符,反过来讲,即使是完全不同的字符,它们的字形也可能是相同的。
Unicode 的职责很明确:它只负责为每个独特的语义单位(字符)分配唯一的数字标识(码点),而不干涉这些字符最终如何显示。字形的选择和渲染则交给字体和排版系统处理。这种设计体现了 Unicode 的核心目标:在尊重各种书写系统独特性的基础上,建立一个统一的字符编码标准。
- 混淆字符(Confusables):这是一种更广泛的视觉欺骗手段,不仅包括单个字符的替换,还包括多个字符组合产生的视觉混淆。例如,在某些字体下,字母组合
r
加上n
的效果rn
可能会看起来像单个字母m
。攻击者可能利用这种特性构造具有欺骗性的文本内容。
这些安全风险提醒我们,在处理用户输入和显示文本时,需要特别注意字符的规范化处理,并考虑实施适当的安全措施,如域名检查、字符过滤等。
总结
通过本文的讲解,我们揭开了字符编码的秘密。Unicode 本质上是一个巨大的字符集,它为每个字符分配了唯一的码点(二进制数字),就像是给每个字符分配了一个独特的身份证号。但 Unicode 并不关心这个”身份证号”如何存储在计算机中,这就是字符编码方案要解决的问题。
UTF(Unicode Transformation Format)系列编码方案就是为了解决这个存储问题而设计的。其中 UTF-8 采用了巧妙的变长编码设计,既能保持对 ASCII 的完全兼容,又能高效地存储各种语言的字符。UTF-16 和 UTF-32 则各有特色,分别适用于不同的应用场景。这些编码方案就像是不同的”压缩算法”,它们都在尝试用最优的方式将 Unicode 字符存储到计算机中。
理解了 Unicode 和 UTF 的关系,我们就能更好地处理文本数据。作为开发者,这些知识不仅能帮助我们避免常见的编码问题,还能让我们在设计系统时做出更明智的编码方案选择。毕竟在这个全球化的互联网时代,正确处理多语言文本已经成为了基本要求。
你还想了解哪些编码问题?欢迎留言讨论!!!